policies.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. import { ident, safeSql } from '@supabase/pg-meta'
  2. import type { PGTable } from '@supabase/pg-meta'
  3. import { PermissionAction } from '@supabase/shared-types/out/constants'
  4. import { LOCAL_STORAGE_KEYS, useParams } from 'common'
  5. import { Search, X } from 'lucide-react'
  6. import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
  7. import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
  8. import { toast } from 'sonner'
  9. import { Button } from 'ui'
  10. import { Input } from 'ui-patterns/DataInputs/Input'
  11. import { PageContainer } from 'ui-patterns/PageContainer'
  12. import {
  13. PageHeader,
  14. PageHeaderAside,
  15. PageHeaderDescription,
  16. PageHeaderMeta,
  17. PageHeaderSummary,
  18. PageHeaderTitle,
  19. } from 'ui-patterns/PageHeader'
  20. import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
  21. import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
  22. import { useIsInlineEditorEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
  23. import { useIsRLSTesterEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
  24. import { Policies } from '@/components/interfaces/Auth/Policies/Policies'
  25. import { PoliciesDataProvider } from '@/components/interfaces/Auth/Policies/PoliciesDataContext'
  26. import { getGeneralPolicyTemplates } from '@/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants'
  27. import { PolicyEditorPanel } from '@/components/interfaces/Auth/Policies/PolicyEditorPanel'
  28. import {
  29. generatePolicyUpdateSQL,
  30. type Policy,
  31. } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils'
  32. import { RLSTesterSheet } from '@/components/interfaces/Auth/RLSTester/RLSTesterSheet'
  33. import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout'
  34. import { DefaultLayout } from '@/components/layouts/DefaultLayout'
  35. import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
  36. import { AlertError } from '@/components/ui/AlertError'
  37. import { AutoEnableRLSNotice } from '@/components/ui/AutoEnableRLSNotice'
  38. import { BannerRlsTester } from '@/components/ui/BannerStack/Banners/BannerRlsTester'
  39. import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
  40. import { DocsButton } from '@/components/ui/DocsButton'
  41. import { NoPermission } from '@/components/ui/NoPermission'
  42. import { SchemaSelector } from '@/components/ui/SchemaSelector'
  43. import { Shortcut } from '@/components/ui/Shortcut'
  44. import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
  45. import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
  46. import { useTablesQuery } from '@/data/tables/tables-query'
  47. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  48. import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
  49. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  50. import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
  51. import { DOCS_URL } from '@/lib/constants'
  52. import { onSearchInputEscape } from '@/lib/keyboard'
  53. import { useEditorPanelStateSnapshot } from '@/state/editor-panel-state'
  54. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  55. import { useShortcut } from '@/state/shortcuts/useShortcut'
  56. import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
  57. import type { NextPageWithLayout } from '@/types'
  58. /**
  59. * Filter tables by table name and policy name
  60. *
  61. * @param tables list of table
  62. * @param policies list of policy
  63. * @param searchString filter keywords
  64. *
  65. * @returns list of table
  66. */
  67. const getTableFilterState = (tables: PGTable[], policies: Array<Policy>, searchString?: string) => {
  68. const sortedTables = tables.slice().sort((a, b) => a.name.localeCompare(b.name))
  69. const visibleTableIds = new Set<number>()
  70. if (!searchString) {
  71. sortedTables.forEach((table) => visibleTableIds.add(table.id))
  72. return { tables: sortedTables, visibleTableIds }
  73. }
  74. const filter = searchString.toLowerCase()
  75. const matchingPolicyKeys = new Set(
  76. policies
  77. .filter((policy: Policy) => policy.name.toLowerCase().includes(filter))
  78. .map((policy) => `${policy.schema}.${policy.table}`)
  79. )
  80. sortedTables.forEach((table) => {
  81. const matches =
  82. table.name.toLowerCase().includes(filter) ||
  83. table.id.toString() === filter ||
  84. matchingPolicyKeys.has(`${table.schema}.${table.name}`)
  85. if (matches) {
  86. visibleTableIds.add(table.id)
  87. }
  88. })
  89. return { tables: sortedTables, visibleTableIds }
  90. }
  91. const AuthPoliciesPage: NextPageWithLayout = () => {
  92. const [schema, setSchema] = useQueryState(
  93. 'schema',
  94. parseAsString.withDefault('public').withOptions({ history: 'replace' })
  95. )
  96. const [searchString, setSearchString] = useQueryState(
  97. 'search',
  98. parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
  99. )
  100. const deferredSearchString = useDeferredValue(searchString)
  101. const [selectedIdToEdit, setSelectedIdToEdit] = useQueryState(
  102. 'edit',
  103. parseAsString.withOptions({ history: 'push', clearOnDefault: true })
  104. )
  105. const { ref: projectRef } = useParams()
  106. const { data: project } = useSelectedProjectQuery()
  107. const { data: postgrestConfig } = useProjectPostgrestConfigQuery({ projectRef: project?.ref })
  108. const isInlineEditorEnabled = useIsInlineEditorEnabled()
  109. const rlsTesterEnabled = useIsRLSTesterEnabled()
  110. const { openSidebar } = useSidebarManagerSnapshot()
  111. const {
  112. setValue: setEditorPanelValue,
  113. setTemplates: setEditorPanelTemplates,
  114. setInitialPrompt: setEditorPanelInitialPrompt,
  115. } = useEditorPanelStateSnapshot()
  116. const [selectedTable, setSelectedTable] = useState<string>()
  117. const [showCreatePolicy, setShowCreatePolicy] = useQueryState(
  118. 'new',
  119. parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
  120. )
  121. const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
  122. const searchInputRef = useRef<HTMLInputElement>(null)
  123. useShortcut(
  124. SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
  125. () => {
  126. searchInputRef.current?.focus()
  127. searchInputRef.current?.select()
  128. },
  129. { label: 'Search policies' }
  130. )
  131. useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => setSearchString(''))
  132. const { isSchemaLocked } = useIsProtectedSchema({ schema: schema, excludedSchemas: ['realtime'] })
  133. const { addBanner, dismissBanner } = useBannerStack()
  134. const [isAutoEnableRLSMinimized] = useLocalStorageQuery(
  135. LOCAL_STORAGE_KEYS.RLS_EVENT_TRIGGER_BANNER_DISMISSED(projectRef ?? ''),
  136. false
  137. )
  138. const [isRlsTesterBannerDismissed] = useLocalStorageQuery(
  139. LOCAL_STORAGE_KEYS.RLS_TESTER_BANNER_DISMISSED(projectRef ?? ''),
  140. false
  141. )
  142. const {
  143. data: policies = [],
  144. isPending: isLoadingPolicies,
  145. isError: isPoliciesError,
  146. isSuccess: isPoliciesSuccess,
  147. error: policiesError,
  148. } = useDatabasePoliciesQuery({
  149. projectRef: project?.ref,
  150. connectionString: project?.connectionString,
  151. })
  152. const selectedPolicyToEdit = policies.find((policy) => policy.id.toString() === selectedIdToEdit)
  153. const {
  154. data: tables,
  155. isPending: isLoading,
  156. isSuccess,
  157. isError,
  158. error,
  159. } = useTablesQuery({
  160. projectRef: project?.ref,
  161. connectionString: project?.connectionString,
  162. schema: schema,
  163. })
  164. const { tables: tablesWithVisibility, visibleTableIds } = useMemo(
  165. () => getTableFilterState(tables ?? [], policies ?? [], searchString),
  166. [tables, policies, searchString]
  167. )
  168. const exposedSchemas = useMemo(() => {
  169. if (!postgrestConfig?.db_schema) return []
  170. return postgrestConfig.db_schema
  171. .split(',')
  172. .map((schema) => schema.trim())
  173. .filter((schema) => schema.length > 0)
  174. }, [postgrestConfig?.db_schema])
  175. const { can: canReadPolicies, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
  176. PermissionAction.TENANT_SQL_ADMIN_READ,
  177. 'policies'
  178. )
  179. const handleSelectCreatePolicy = useCallback(
  180. (table: string) => {
  181. setSelectedTable(table)
  182. setSelectedIdToEdit(null)
  183. setShowCreatePolicy(true)
  184. if (isInlineEditorEnabled) {
  185. const defaultSql = safeSql`create policy "replace_with_policy_name"
  186. on ${ident(schema)}.${ident(table)}
  187. for select
  188. to authenticated
  189. using (
  190. true -- Write your policy condition here
  191. );`
  192. setEditorPanelInitialPrompt('Create a new RLS policy that...')
  193. setEditorPanelValue(defaultSql)
  194. setEditorPanelTemplates([])
  195. openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
  196. } else {
  197. setShowCreatePolicy(true)
  198. }
  199. },
  200. [isInlineEditorEnabled, openSidebar, schema]
  201. )
  202. const handleSelectEditPolicy = useCallback(
  203. (policy: Policy) => {
  204. setSelectedTable(undefined)
  205. if (isInlineEditorEnabled) {
  206. setEditorPanelInitialPrompt(`Update the RLS policy with name "${policy.name}" that...`)
  207. setEditorPanelValue(generatePolicyUpdateSQL(policy))
  208. const templates = getGeneralPolicyTemplates(policy.schema, policy.table).map(
  209. (template) => ({
  210. name: template.templateName,
  211. description: template.description,
  212. content: template.statement,
  213. })
  214. )
  215. setEditorPanelTemplates(templates)
  216. openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
  217. } else {
  218. setSelectedIdToEdit(policy.id.toString())
  219. }
  220. },
  221. [isInlineEditorEnabled, openSidebar]
  222. )
  223. const handleResetSearch = useCallback(() => setSearchString(''), [setSearchString])
  224. useEffect(() => {
  225. if (rlsTesterEnabled) return
  226. if (!isRlsTesterBannerDismissed) {
  227. addBanner({
  228. id: 'rls-tester-banner',
  229. isDismissed: false,
  230. content: <BannerRlsTester />,
  231. priority: 3,
  232. })
  233. } else {
  234. dismissBanner('rls-tester-banner')
  235. }
  236. return () => {
  237. dismissBanner('rls-tester-banner')
  238. }
  239. }, [addBanner, dismissBanner, isRlsTesterBannerDismissed, rlsTesterEnabled])
  240. useEffect(() => {
  241. if (selectedIdToEdit && isPoliciesSuccess && !selectedPolicyToEdit) {
  242. toast(`Policy ID ${selectedIdToEdit} cannot be found`)
  243. setSelectedIdToEdit(null)
  244. }
  245. }, [selectedIdToEdit, selectedPolicyToEdit, isPoliciesSuccess, setSelectedIdToEdit])
  246. const isUpdatingPolicy = !!selectedIdToEdit
  247. if (isPermissionsLoaded && !canReadPolicies) {
  248. return <NoPermission isFullPage resourceText="view this project's RLS policies" />
  249. }
  250. return (
  251. <>
  252. <PageHeader size="large">
  253. <PageHeaderMeta>
  254. <PageHeaderSummary>
  255. <PageHeaderTitle>Policies</PageHeaderTitle>
  256. <PageHeaderDescription>
  257. Manage Row Level Security policies for your tables
  258. </PageHeaderDescription>
  259. </PageHeaderSummary>
  260. <PageHeaderAside>
  261. {isAutoEnableRLSMinimized && <AutoEnableRLSNotice iconOnly />}
  262. <DocsButton href={`${DOCS_URL}/learn/auth-deep-dive/auth-row-level-security`} />
  263. {rlsTesterEnabled && <RLSTesterSheet handleSelectEditPolicy={handleSelectEditPolicy} />}
  264. </PageHeaderAside>
  265. </PageHeaderMeta>
  266. </PageHeader>
  267. <PageContainer size="large">
  268. <PageSection>
  269. {!isAutoEnableRLSMinimized && <AutoEnableRLSNotice />}
  270. <PageSectionContent>
  271. <div className="mb-4 flex flex-row gap-x-2">
  272. <Shortcut
  273. id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
  274. onTrigger={() => setSchemaSelectorOpen(true)}
  275. side="bottom"
  276. tooltipOpen={schemaSelectorOpen ? false : undefined}
  277. >
  278. <SchemaSelector
  279. className="w-full lg:w-[180px]"
  280. size="tiny"
  281. align="end"
  282. showError={false}
  283. selectedSchemaName={schema}
  284. open={schemaSelectorOpen}
  285. onOpenChange={setSchemaSelectorOpen}
  286. onSelectSchema={(schemaName) => {
  287. setSchema(schemaName)
  288. setSearchString('')
  289. }}
  290. />
  291. </Shortcut>
  292. <Input
  293. ref={searchInputRef}
  294. size="tiny"
  295. placeholder="Filter tables and policies"
  296. className="block w-full lg:w-52"
  297. containerClassName="[&>div>svg]:-mt-0.5"
  298. value={searchString || ''}
  299. onChange={(e) => {
  300. const str = e.target.value
  301. setSearchString(str)
  302. }}
  303. onKeyDown={onSearchInputEscape(searchString || '', setSearchString)}
  304. icon={<Search size={14} />}
  305. actions={
  306. searchString ? (
  307. <Button
  308. size="tiny"
  309. type="text"
  310. className="p-0 h-5 w-5"
  311. icon={<X />}
  312. onClick={() => setSearchString('')}
  313. />
  314. ) : null
  315. }
  316. />
  317. </div>
  318. {isLoading && <GenericSkeletonLoader />}
  319. {isError && <AlertError error={error} subject="Failed to retrieve tables" />}
  320. {isSuccess && (
  321. <PoliciesDataProvider
  322. policies={policies ?? []}
  323. isPoliciesLoading={isLoadingPolicies}
  324. isPoliciesError={isPoliciesError}
  325. policiesError={policiesError ?? undefined}
  326. exposedSchemas={exposedSchemas}
  327. >
  328. <Policies
  329. search={deferredSearchString}
  330. schema={schema}
  331. tables={tablesWithVisibility}
  332. hasTables={(tables ?? []).length > 0}
  333. isLocked={isSchemaLocked}
  334. visibleTableIds={visibleTableIds}
  335. onSelectCreatePolicy={handleSelectCreatePolicy}
  336. onSelectEditPolicy={handleSelectEditPolicy}
  337. onResetSearch={handleResetSearch}
  338. />
  339. </PoliciesDataProvider>
  340. )}
  341. <PolicyEditorPanel
  342. visible={showCreatePolicy || (isUpdatingPolicy && !!selectedPolicyToEdit)}
  343. schema={schema}
  344. searchString={searchString}
  345. selectedTable={isUpdatingPolicy ? undefined : selectedTable}
  346. selectedPolicy={isUpdatingPolicy ? selectedPolicyToEdit : undefined}
  347. onSelectCancel={() => {
  348. setSelectedTable(undefined)
  349. if (isUpdatingPolicy) {
  350. setSelectedIdToEdit(null)
  351. } else {
  352. setShowCreatePolicy(false)
  353. }
  354. }}
  355. authContext="database"
  356. />
  357. </PageSectionContent>
  358. </PageSection>
  359. </PageContainer>
  360. </>
  361. )
  362. }
  363. AuthPoliciesPage.getLayout = (page) => (
  364. <DefaultLayout>
  365. <AuthLayout title="Policies">{page}</AuthLayout>
  366. </DefaultLayout>
  367. )
  368. export default AuthPoliciesPage